Arquitectura: tres módulos, dos puertos
Toca ahora abrir el módulo introducido en el capítulo anterior y ver cómo está construido por dentro, y desde ahí explicar de qué forma habla con los otros dos módulos que intervienen en el flujo: el de embeddings de archivos, que se desarrolla en este TFG, y la librería de GCS, que ya existía en la plataforma.
Para ello dwall-module-files sigue al pie de la letra el reparto en tres capas que vimos en el capítulo de DDD:
dwall-module-files/
├── files-domain/ # núcleo, sin dependencias externas
│ ├── FileResource.java # agregado raíz
│ ├── FileId / FileName / OwnerId / … # value objects
│ ├── ContentType / BucketPath # value objects
│ ├── FileStatus / ResourceType # enums
│ └── api/
│ ├── FileRepository.java # puerto persistencia (PostgreSQL)
│ ├── FileStoragePort.java # puerto almacenamiento (GCS)
│ ├── FileEmbeddingPort.java # puerto embeddings
│ └── FileUserPort.java # puerto usuarios
├── files-app/ # casos de uso
│ └── service/
│ ├── FileService.java # orquestador de la subida
│ └── FileProcessor.java # dispatch asíncrono a los puertos
└── files-persistence/ # adaptadores
├── repository/FileRepositoryPostgresImpl.java
└── storage/GcsFileStorageAdapter.javaEl detalle importante está en files-domain/api: cuatro puertos declarados como interfaces. Dos de ellos —FileRepository y FileStoragePort— se implementan dentro del propio módulo, en files-persistence. Los otros dos —FileEmbeddingPort y FileUserPort— se declaran aquí pero los implementa otro módulo, lo que mantiene la dependencia siempre en el sentido correcto del hexágono: hacia dentro.
La orquestación: FileService.upload
El caso de uso de subida vive en FileService y es la pieza que ata el hexágono entero:
public String upload(final MultipartFile multipartFile, final String username, final String resourceType) {
try {
final OwnerId ownerId = OwnerId.of(fileUserPort.findIdByUsername(username));
final FileResource file = FileResource.create(
FileName.of(multipartFile.getOriginalFilename()),
ownerId,
ContentType.of(multipartFile.getContentType()),
ResourceType.fromString(resourceType)
);
fileRepository.insert(file);
eventBus.publish(new FileCreated(file.id().asString(), file.name().value()));
fileProcessor.process(file, multipartFile.getBytes());
return file.id().asString();
} catch (final IOException e) {
throw new RuntimeException("Failed to read uploaded file content", e);
}
}El flujo es lineal y se lee en cinco pasos. Primero resuelve el OwnerId a partir del nombre de usuario tirando del FileUserPort — un puerto que apunta al módulo de usuarios y devuelve el id interno de la plataforma. Después construye el FileResource con los datos del MultipartFile y lo persiste en PostgreSQL vía FileRepository, lo que asigna el FileId definitivo. A continuación publica un FileCreated en el EventBus para que cualquier listener interesado se entere (creando el mirror de su descripción, por ejemplo). Y por último delega en FileProcessor el trabajo pesado: subir el binario a GCS y arrancar el embedding. Ambos procesos son totalmente asíncronos, por lo que la devolución del FileId al cliente HTTP es inmediata, sin esperar a que termine ese trabajo de fondo.
Es buen momento para introducir el patrón choreography: el estado del archivo no lo gobierna un orquestador central, sino que cada módulo reacciona a los eventos del flujo y mueve el estado por su cuenta. Un archivo recién subido entra como PENDING, pasa a PROCESSING cuando algún listener empieza a trabajar sobre él, y termina en COMPLETED o FAILED según el desenlace. FileService ni siquiera ve esos estados intermedios; se limita a publicar FileCreated y a devolver el FileId al cliente.
El módulo de embeddings sigue la misma idea con un matiz importante: como embebir un archivo es opcional —no todas las marcas activan esta característica—, su estado no contamina al agregado FileResource. En su lugar mantiene una tabla mirror dentro del propio módulo de embeddings, con su propio ciclo de estados desacoplado del de la subida del archivo.
Dispatch a los puertos: FileProcessor
FileProcessor es el sitio donde el módulo de archivos habla con los otros dos. Es la frontera del hexágono y, salvando el @Async que lo tira a un hilo aparte, no hace nada más que invocar los dos puertos de salida en orden:
@Service
public class FileProcessor {
private final FileStoragePort fileStoragePort;
private final FileEmbeddingPort fileEmbeddingPort;
@Async
public void process(final FileResource file, final byte[] content) {
fileStoragePort.store(file.bucketPath(), content, file.contentType());
fileEmbeddingPort.embed(file.id(), content, file.contentType());
}
}La primera línea —fileStoragePort.store(...)— sube el binario a GCS. La implementación detrás de ese puerto es GcsFileStorageAdapter, que envuelve la librería acquisition-google-storage-library que DWall ya tenía y por eso no se desarrolla en este TFG. La segunda línea —fileEmbeddingPort.embed(...)— arranca el pipeline de embeddings sobre el contenido del archivo. Esa implementación sí es nueva: vive en dwall-module-embeddings-files-persistence y es la que se desarrolla en el capítulo siguiente.
Lo interesante de esta clase es lo que no hace. No conoce a Google Cloud Storage. No conoce a Gemini. No conoce a dwall-module-embeddings-files. Solo conoce dos interfaces declaradas en su propio dominio, y deja que el contenedor de Spring resuelva cuál es la implementación concreta de cada una en tiempo de arranque. El módulo de archivos podría arrancar mañana sin GCS y sin embeddings —cambiando los adaptadores por implementaciones no-op— y ni FileService ni FileProcessor se enterarían.
Este diseño, sin embargo, tiene un coste: el módulo de archivos sigue declarando dependencias Maven sobre los módulos que implementan sus puertos. En un DDD estricto los contextos acotados deberían comunicarse únicamente mediante eventos de dominio, sin acoplamiento directo en tiempo de compilación.
Nustro caso rompe este paradigma, pero por una razón pragmática: los eventos de dominio no pueden transportar payloads pesados, un archivo pdf de varios MB saturaría el bus y volvería el sistema frágil ante fallos, por lo que la subida del archivo y el arranque del pipeline de embeddings se modelan como llamadas asíncronas a través de puertos. Es una concesión consciente: la abstracción que aportan las interfaces basta para que cada módulo evolucione de forma independiente, aunque el grafo de Maven refleje un acoplamiento que el grafo de dominio no tiene.
El agregado FileResource
El agregado es deliberadamente simple. No tiene reglas de negocio complejas — un archivo no necesita validar invariantes elaboradas — pero sí encapsula los campos imprescindibles del recurso y deja que FileStatus refleje el punto del flujo en el que se encuentra:
public final class FileResource {
private FileId id;
private final FileName name;
private final OwnerId ownerId;
private final ContentType contentType;
private final ResourceType resourceType;
private BucketPath bucketPath;
private FileStatus uploadStatus;
public static FileResource create(...) {
// estado inicial: id null, bucketPath null, status PENDING
}
}El adaptador de persistencia
En la capa de persistencia, FileRepositoryPostgresImpl traduce el agregado a la tabla module_files_file vía jOOQ. El método relevante para esta historia es insert, porque es el que dispara FileService justo después de crear el FileResource:
@Override
public FileId insert(final FileResource file) {
final Long id = context.insertInto(MODULE_FILES_FILE)
.set(MODULE_FILES_FILE.NAME, file.name().value())
.set(MODULE_FILES_FILE.OWNER_ID, file.ownerId().value())
.set(MODULE_FILES_FILE.CONTENT_TYPE, file.contentType().value())
.set(MODULE_FILES_FILE.RESOURCE_TYPE, file.resourceType().name())
.set(MODULE_FILES_FILE.UPLOAD_STATUS, file.uploadStatus().name())
.returningResult(MODULE_FILES_FILE.ID)
.fetchOne()
.getValue(MODULE_FILES_FILE.ID);
final FileId fileId = FileId.of(id);
file.assignId(fileId);
return fileId;
}El insert solo escribe metadatos — nombre, dueño, tipo MIME, tipo de recurso y estado. El BucketPath se rellena después con un update, una vez GCS confirma que el binario está guardado. La estructura concreta de la tabla la dejamos para el capítulo donde unifiquemos el esquema de los dos módulos a la vez.